查看原文
其他

基于 Redis 的分布式锁的分析与实践,附源码

菜蚜 搜云库技术团队 2019-11-01
点击上方"搜云库技术团队"关注

选择"设为星标"
技术 / 架构 / 资料 / 面试 / 内推

前言:在分布式环境中,我们经常使用锁来进行并发控制,锁可分为乐观锁和悲观锁,基于数据库版本戳的实现是乐观锁,基于Redis或zookeeper的实现可认为是悲观锁了。乐观锁和悲观锁最根本的区别在于线程之间是否相互阻塞。

本文主要来讨论基于redis的分布式锁算法问题

从2.6.12版本开始,redis为 SET命令增加了一系列选项(S ET key value[EX seconds][PX milliseconds][NX|XX]):

EX seconds – 设置键key的过期时间,单位时秒 PX milliseconds – 设置键key的过期时间,单位时毫秒 NX 只有键key不存在的时候才会设置key的值 XX 只有键key存在的时候才会设置key的值

原文地址:https://redis.io/commands/set

中文地址:http://redis.cn/commands/set.html

注意: 由于 SET命令加上选项已经可以完全取代SETNX, SETEX, PSETEX的功能,所以在将来的版本中,redis可能会不推荐使用并且最终抛弃这几个命令。

(这里简单提一下,在旧版本的redis中(指2.6.12版本之前),使用redis实现分布式锁一般需要setNX、expire、getSet、del等命令。而且会发现这种实现有很多逻辑判断的原子操作以及本地时间等并没有控制好。)

而在旧版本的redis中,redis的超时时间很难控制,用户迫切需要把setNX和expiration结合为一体的命令,把他们作为一个原子操作,这样新版本的多选项set命令诞生了。然而这并没有完全解决复杂的超时控制带来的问题。整编:微信公众号,搜云库技术团队,ID:souyunku

接下来,我们的一切讨论都基于新版redis。

在这里,我先提出几个在实现redis分布式锁中需要考虑的关键问题

1、死锁问题

1.1、为了防止死锁,redis至少需要设置一个超时时间;

由1.1引申出来,当锁自动释放了,但是程序并没有执行完毕,这时候其他线程又获取到锁执行同样的程序,可能会造成并发问题,这个问题我们需要考虑一下是否归属于分布式锁带来问题的范畴。

2、锁释放问题,这里会有两个问题

2.1、每个获取redis锁的线程应该释放自己获取到的锁,而不是其他线程的,所以我们需要在每个线程获取锁的时候给锁做上不同的标记以示区分;

2.2、由2.1带来的问题是线程在释放锁的时候需要判断当前锁是否属于自己,如果属于自己才释放,这里涉及到逻辑判断语句,至少是两个操作在进行,那么我们需要考虑这两个操作要在一个原子内执行,否者在两个行为之间可能会有其他线程插入执行,导致程序紊乱。

3、更可靠的锁

单实例的redis(这里指只有一个master节点)往往是不可靠的,虽然实现起来相对简单一些,但是会面临着宕机等不可用的场景,即使在主从复制的时候也显得并不可靠(因为redis的主从复制往往是异步的)。

关于Martin Kleppmann的Redlock的分析

原文地址:https://redis.io/topics/distlock

中文地址:http://redis.cn/topics/distlock.html

文章分析得出,这种算法只需具备3个特性就可以实现一个最低保障的分布式锁。

1、安全属性(Safety property)

独享(相互排斥)。在任意一个时刻,只有一个客户端持有锁。

2、活性A(Liveness property A)

无死锁。即便持有锁的客户端崩溃(crashed)或者网络被分裂(gets partitioned),锁仍然可以被获取。

3、活性B(Liveness property B)

容错。 只要大部分Redis节点都活着,客户端就可以获取和释放锁.

我们来分析一下:

第一点安全属性意味着悲观锁(互斥锁)是我们做redis分布式锁的前提,否者将可能造成并发;

第二点表明为了避免死锁,我们需要设置锁超时时间,保证在一定的时间过后,锁可以重新被利用;

第三点是说对于客户端来说,获取锁和手动释放锁可以有更高的可靠性。

更进一步分析,结合上文提到的关键问题,这里可以引申出另外的两个问题:

1、怎么才能合理判断程序真正处理的有效时间范围?(这里有个时间偏移的问题)

2、redis Master节点宕机后恢复(可能还没有持久化到磁盘)、主从节点切换,(N/2)+1这里的N应该怎么动态计算更合理?

接下来再看,redis之父antirez对Redlock的评价

原文地址:

http://antirez.com/news/101

文中主要提到了网络延迟和本地时钟的修改(不管是时间服务器或人为修改)对这种算法可能造成的影响。

最后,来点实践吧

1、传统的单实例redis分布式锁实现(关键步骤)

获取锁(含自动释放锁):

  1. SET resource_name my_random_value NX PX 30000

手动删除锁(Lua脚本):

  1. if redis.call("get",KEYS[1]) == ARGV[1] then

  2. return redis.call("del",KEYS[1])

  3. else

  4. return 0

  5. end

2、分布式环境的redis(多master节点)的分布式锁实现

为了保证在尽可能短的时间内获取到(N/2)+1个节点的锁,可以并行去获取各个节点的锁(当然,并行可能需要消耗更多的资源,因为串行只需要count到足够数量的锁就可以停止获取了);整编:微信公众号,搜云库技术团队,ID:souyunku

另外,怎么动态实时统一获取redis master nodes需要更进一步去思考了。

QA,补充一下说明(以下为我与朋友沟通的情况,以说明文中大家可能不够明白的地方):

1、在关键问题2.1中,删除就删除了,会造成什么问题?

线程A超时,准备删除锁;但此时的锁属于线程B;线程B还没执行完,线程A把锁删除了,这时线程C获取到锁,同时执行程序;所以不能乱删。

2、在关键问题2.2中,只要在key生成时,跟线程相关就不用考虑这个问题了吗?

不同的线程执行程序,线程之间肯虽然有差异呀,然后在redis锁的value设置有线程信息,比如线程id或线程名称,是分布式环境的话加个机器id前缀咯(类似于twitter的snowflake算法!),但是在del命令只会涉及到key,不会再次检查value,所以还是需要lua脚本控制if(condition){xxx}的原子性。

3、那要不要考虑锁的重入性?

不需要重入;try...finally 没得重入的场景;对于单个线程来说,执行是串行的,获取锁之后必定会释放,因为finally的代码必定会执行啊(只要进入了try块,finally必定会执行)。

4、为什么两个线程都会去删除锁?(貌似重复的问题。不管怎样,还是耐心解答吧)

每个线程只能管理自己的锁,不能管理别人线程的锁啊。这里可以联想一下ThreadLocal。

5、如果加锁的线程挂了怎么办?只能等待自动超时?

看你怎么写程序的了,一种是问题3的回答;另外,那就自动超时咯。这种情况也适用于网络over了。

6、时间太长,程序异常就会蛋疼,时间太短,就会出现程序还没有处理完就超时了,这岂不是很尴尬?

是呀,所以需要更好的衡量这个超时时间的设置。

实践部分,公平锁(Fair Lock)代码

基于Redis的Redisson分布式可重入公平锁也是实现了 java.util.concurrent.locks.Lock接口的一种 RLock对象。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。它保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。

  1. RLock fairLock = redisson.getFairLock("anyLock");

  2. // 最常见的使用方法

  3. fairLock.lock();

大家都知道,如果负责储存这个分布式锁的Redis节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。

另外Redisson还通过加锁的方法提供了 leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。

  1. // 10秒钟以后自动解锁

  2. // 无需调用unlock方法手动解锁

  3. fairLock.lock(10, TimeUnit.SECONDS);


  4. // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁

  5. boolean res = fairLock.tryLock(100, 10, TimeUnit.SECONDS);

  6. ...

  7. fairLock.unlock();

Redisson同时还为分布式可重入公平锁提供了异步执行的相关方法:

  1. RLock fairLock = redisson.getFairLock("anyLock");

  2. fairLock.lockAsync();

  3. fairLock.lockAsync(10, TimeUnit.SECONDS);

  4. Future<Boolean> res = fairLock.tryLockAsync(100, 10, TimeUnit.SECONDS);

实践部分,红锁(RedLock)代码

基于Redis的Redisson红锁 RedissonRedLock对象实现了Redlock介绍的加锁算法。该对象也可以用来将多个 RLock对象关联为一个红锁,每个 RLock对象实例可以来自于不同的Redisson实例。

  1. RLock lock1 = redissonInstance1.getLock("lock1");

  2. RLock lock2 = redissonInstance2.getLock("lock2");

  3. RLock lock3 = redissonInstance3.getLock("lock3");


  4. RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);

  5. // 同时加锁:lock1 lock2 lock3

  6. // 红锁在大部分节点上加锁成功就算成功。

  7. lock.lock();

  8. ...

  9. lock.unlock();

大家都知道,如果负责储存某些分布式锁的某些Redis节点宕机以后,而且这些锁正好处于锁住的状态时,这些锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。

另外Redisson还通过加锁的方法提供了 leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。

  1. RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);

  2. // 给lock1,lock2,lock3加锁,如果没有手动解开的话,10秒钟后将会自动解开

  3. lock.lock(10, TimeUnit.SECONDS);


  4. // 为加锁等待100秒时间,并在加锁成功10秒钟后自动解开

  5. boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);

  6. ...

  7. lock.unlock();

附加,开源分布式锁实现代码

基于redis的分布式锁实现客户端Redisson:

https://github.com/redisson/redisson

基于zookeeper的分布式锁实现:

http://curator.apache.org/curator-recipes/shared-reentrant-lock.html

敬请关注「搜云库技术团队」微信公众号,获取最新文章

版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知我们,我们会立即删除并表示歉意。谢谢!

作者:菜蚜

来源:my.oschina.net/wnjustdoit/blog/1606215

整编:搜云库技术团队,欢迎广大技术人员投稿

投稿邮箱:admin@souyunku.com

如果对本文的内容有疑问,请在文章留言区留言,谢谢。

》》》点击:永不失效的福利《《《

更多技术干货


推荐:最新200篇:技术文章整理 

1、不小心执行 rm -f,该如何恢复? 
2、
面试必备:深入 Java 应用性能调优实践 
3、
成为Java顶尖程序员,先过了下面问题! 
4、
说出 JVM发生CMS GC的 5 种情况 
5、
Kafka竟然不支持读写分离!今天才知道! 
6、
不懂Spring的9种设计模式,面试会吃亏的

Modified on

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存